crontab — 定时任务调度详解(含 systemd timer 对比)
crontab(cron table)是 Linux 下最经典的定时任务方案:在配置表里写"某分某时某日执行某命令",由 crond 守护进程每分钟扫描一次,命中即执行。它由 cronie(vixie-cron 的后继实现)提供,每行一个任务,语法极简、人人都会。
本页先按规范梳理 crontab 本身,再单独一章对比 systemd timer,回答"能否直接替代"。
1. 概述
1.1 cron 的三个角色
- crond(守护进程)— 系统启动时运行(systemd 下为
crond.service),每 60 秒醒来扫描所有任务表并执行命中任务。 - crontab(命令)— 用户管理自己任务表的工具:
-e编辑、-l列出、-r删除、-T测试语法。 - crontab 文件(配置)— 存放任务条目的文本,用户任务在
/var/spool/cron/<用户>,系统任务在/etc/crontab与/etc/cron.d/。
Persistent=yes 完全不同。
- cron — 定时调度机制本身(守护进程 + 文件 + 命令的统称)。
- crond / cronie — cron 的具体实现守护进程 / 发行版软件包名。
- crontab 条目 — 任务表里的一行有效配置(环境设置 或 时间+命令)。
- anacron — cron 的补充,负责"每天/每周/每月"级别错过的任务补跑。
- run-parts — 依次执行一个目录下所有可执行脚本的工具(配合目录级 cron)。
- systemd timer — systemd 原生的定时单元(.timer),本页第 5 章的对比对象。
1.2 crontab 命令速览
crontab -l # 列出当前用户任务表 crontab -e # 编辑任务表(保存后自动安装) crontab -r -i # 删除任务表(-i 二次确认) crontab -T <file> # 只测语法不安装 crontab -u alice -l # 管理 alice 的任务表(root 可用) crontab <file> # 用文件内容整体替换任务表 crontab -V # 查看版本
2. 行为图(系统"做什么")
2.1 用例图 — cron 交互全景
用例演示
crontab -e # 打开编辑器编辑自己的任务 crontab -T ~/mytab # 测试语法后再安装 sudo crontab -u backup -l # 查看 backup 用户的任务表 sudo tee /etc/cron.d/myjobs # 往系统分表写入任务
2.2 活动图 — crond 调度主流程
crond 的生命循环:加载任务表 → 每分钟匹配 → 命中则 fork 子进程执行并处理输出。
用例演示
# 任务执行后想收到输出邮件 MAILTO=ops@example.com 30 2 * * * /usr/local/bin/backup.sh # 输出会由 crond 发给 MAILTO;设为 MAILTO="" 则丢弃
/bin/sh -c 执行命令,命令所见环境(SHELL/PATH/HOME/LOGNAME)由 crond 构造。
2.3 状态机图 — cron 任务生命周期
任务表中一条条目的状态流转:从被加载到每次执行、再回到等待下一次触发。
2.4 时序图 — 一次 cron 执行
从用户编辑任务表,到 crond 真正执行命令的完整链路。
crontab 命令校验语法后把编辑结果写入 /var/spool/cron/。
3. 结构图(系统"由什么组成")
3.1 部署图 — cron 生态
crond 守护进程、各类任务表、anacron、日志与邮件的协作拓扑。
用例演示
systemctl status crond # 查看 cron 服务状态 journalctl -u crond -f # 跟踪 crond 日志 ls /etc/cron.daily /etc/cron.hourly # 目录级任务脚本
run-parts(或 anacron)在固定时间执行;anacron 会记录上次运行时间,系统开机补跑错过的每日任务。
3.2 组件图 — crond 内部
把 crond 进程拆开:加载、校验、匹配、执行、通知各司其职。
用例演示
# 权限控制:只允许白名单用户用 crontab echo "root" > /etc/cron.allow echo "alice" >> /etc/cron.allow # 其余用户(含新建用户)一律无法 crontab -e
3.3 包图 — 文件布局
| 位置 | 类型 | 条目格式 | 用途 |
|---|---|---|---|
/etc/crontab | 系统主表 | 时间字段 + 用户名 + 命令 | 系统级任务,需 root 编辑 |
/etc/cron.d/ | 系统分表 | 同上(含用户名) | 软件包分发自己的任务,如 cron-apt |
/var/spool/cron/<user> | 用户表 | 时间字段 + 命令(无用户名) | 普通用户经 crontab 命令维护,勿直接编辑 |
/etc/cron.{hourly,daily,...}/ | 目录级 | 直接放可执行脚本 | run-parts / anacron 顺序执行 |
3.4 类图 — 对象模型
* , - / ~ 与名称;CrontabEntry 是"5 个时间字段 + 命令"的最小单位;EnvSetting 是任务表顶部的 KEY=value 行,被同表所有条目继承。
4. 语法规范
crontab 语法是实战最容易出错的点,尤其"日与星期是 OR 逻辑"这一点全网误解率最高。
4.1 时间字段
一条 cron 命令 = 5 个时间字段 + 命令(系统级文件还要加一个用户名):
分 时 日 月 星期 [用户名] 命令 * * * * * root /usr/local/bin/backup.sh
| 字段 | 取值范围 | 别名 |
|---|---|---|
| minute(分) | 0-59 | — |
| hour(时) | 0-23 | — |
| day of month(日) | 1-31 | — |
| month(月) | 1-12 | jan,feb,...dec(前三个字母,大小写不敏感) |
| day of week(星期) | 0-7(0 和 7 都是周日) | sun,mon,...sat |
命令在"分、时、月"全部匹配当前时间,且 "日"或"星期"至少一个匹配时执行。夏令时跳过的时刻永不执行、重复的时刻执行两次。
4.2 特殊字符与名称
| 字符 | 含义 | 示例 |
|---|---|---|
* | 任意值(等价"首-末") | 0 * * * * 每小时整点 |
, | 列表 | 1,15 * * * * 每小时第 1、15 分 |
- | 范围(含两端) | 0 9-17 * * * 每天 9-17 点整点 |
/ | 步进(仅作用于本字段) | */15 * * * * 每 15 分钟 |
~ | 随机(cronie 扩展,取范围内随机值) | 6~15 * * * * 每小时的 6-15 分随机一分执行 |
| 名称 | 月/星期可用三字母名,可做范围/列表 | mon,wed,fri、jan-mar |
*/35 不会"每 35 分钟"执行,而是每小时的第 0、35 分钟各执行一次——步进只在字段范围内计算。真正的"每 35 分钟"需用 0-59/35(也是每小时的 0、35 分),要真正按相对间隔执行请考虑 timer 的 OnUnitActiveSec=。
4.3 日(dom) 与 星期(dow) 的 OR 逻辑
当"日"和"星期"两字段都不是 * 时,二者是 OR(任一匹配即执行),这是 cron 与绝大多数人的直觉(AND)不同之处:
30 4 1,15 * 5 # 含义:每月 1 日和 15 日的 4:30,加上每周五的 4:30 —— 而不是"每月1/15日且恰逢周五才执行"
*,命令里用 test 过滤,如 30 4 1 * * test "$(date +%u)" = 5 && cmd(每月 1 日若恰逢周五才执行)。
4.4 环境变量
任务表顶部可写 KEY=value(等号两侧空格可有可无,值里的多余空格会保留,可用引号包裹):
| 变量 | 默认 | 说明 |
|---|---|---|
SHELL | /bin/sh | 执行命令的 shell,可改为 /bin/bash |
PATH | 随发行版(通常 /usr/bin:/bin) | 命令找不到时先检查它(最常见坑) |
HOME | /etc/passwd 中该用户家目录 | 可覆盖 |
LOGNAME | 任务属主 | 不可覆盖 |
MAILTO | 任务属主 | 命令有输出时发邮件给谁;MAILTO="" 不发 |
MAILFROM | 属主用户名 | 邮件发件人(支持 $USER 展开) |
CRON_TZ | 系统时区 | 本任务表使用的时区(表内所有条目按此时区解释) |
RANDOM_DELAY | 无 | 任务随机延迟上限(分钟),错峰用 |
CONTENT_TYPE | text/plain | 邮件内容类型编码 |
/usr/local/bin 等目录,且不加载你的 .bashrc。脚本里请用绝对路径,或在任务表里 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。
4.5 特殊字符串
用 @名字 代替前 5 个时间字段(cronie 扩展):
| 字符串 | 等价写法 | 说明 |
|---|---|---|
@reboot | — | 守护进程启动时执行一次(开机时) |
@yearly / @annually | 0 0 1 1 * | 每年 1 月 1 日 0:00 |
@monthly | 0 0 1 * * | 每月 1 日 0:00 |
@weekly | 0 0 * * 0 | 每周日 0:00 |
@daily / @midnight | 0 0 * * * | 每天 0:00 |
@hourly | 0 * * * * | 每小时整点 |
4.6 % 字符与命令细节
%是特殊字符:命令里未转义的%会被 crond 换成换行;第一个%之后的内容会作为标准输入传给命令。要字面%需写\%。- 命令本身由
/bin/sh -c(或 SHELL)执行,支持管道、重定向、$(...)。 - 空行、行首空格/Tab、
#注释行会被忽略;注释不能与命令同行。 - 每条命令必须以换行结尾,否则 crond 认为文件损坏并警告。
- root 的任务,条目首字符可为
-以抑制 syslog 记录。
用 % 传标准输入
0 22 * * 1-5 mail -s "提醒" ops@example.com %今晚记得更新周报%第二行内容 # 上面的 %% 输出两个换行, % 后的内容作为邮件正文
4.7 系统级 / 用户级 / 目录级
| 层级 | 文件 | 谁维护 | 是否含用户名 |
|---|---|---|---|
| 系统主表 | /etc/crontab | root | 是(第 6 字段) |
| 系统分表 | /etc/cron.d/* | root / 软件包 | 是 |
| 用户表 | /var/spool/cron/<user> | 各用户经 crontab 命令 | 否(命令即以该用户身份运行) |
| 目录级 | /etc/cron.hourly|daily|weekly|monthly/ | root 放脚本 | —(脚本以 root 运行) |
# /etc/cron.d/backup — 软件包分发风格(第 6 字段是用户名) MAILTO=root 30 2 * * * root /usr/local/bin/backup.sh
5. timer 与 crontab 对比与迁移
5.1 架构对比
两者最本质的区别:crontab 依赖一个独立的外部守护进程(crond)去轮询时间;systemd timer 是 PID 1 原生内置的调度机制,与服务的 unit 体系(日志、依赖、重启策略)深度耦合。
5.2 能力对照表
| 能力 | crontab(cronie) | systemd timer |
|---|---|---|
| 属主与集成 | 独立 crond 守护进程 | systemd(PID 1)原生,与服务 unit 一体 |
| 最小粒度 | 分钟 | 秒级(日历精确调度,实测亚秒级) |
| 时间表达 | 5 字段(分时日月周) | OnCalendar 日历式 + 单调时间(OnBootSec / OnUnitActiveSec) |
| 错过的任务 | 错过即错过(仅目录级由 anacron 补) | Persistent=yes 可补跑关机期间错过的触发 |
| 相对间隔 | 不支持(*/35 只是整点对齐) | OnUnitActiveSec=35min 真正"距上次 N 时间后"循环 |
| 随机延迟 | RANDOM_DELAY(分钟级) | RandomizedDelaySec=(秒级) |
| 日志 | syslog;命令输出发邮件 | journald,journalctl -u 直接看;输出可重定向到文件/journal |
| 失败处理 | 无重试,靠邮件通知 | OnFailure=、服务侧 Restart= 联动 |
| 依赖与排序 | 无(@reboot 只是开机后执行) | After=/Requires=/PartOf=,可与挂载点、网络绑定 |
| 用户级任务 | 任何用户 crontab -e(受 allow/deny 控制) | systemctl --user + loginctl enable-linger 持久化 |
| 语法熟悉度 | 极简、跨发行版通用 | OnCalendar 需要学习(形如 Mon *-*-* 09:00:00) |
| 管理面 | crontab 命令 + 手动编辑 | 统一 systemctl:enable/start/status |
| 日与星期 | OR 逻辑(见 4.3) | OnCalendar 多个表达式是 OR,字段内是精确匹配 |
| 集群/网络共享 | 支持共享 /var/spool/cron(-n 指定执行节点) | 无此特性(需自建) |
5.3 迁移对照与结论
常见条目迁移对照
| crontab 写法 | systemd timer 的 OnCalendar / 单调时间 |
|---|---|
*/15 * * * * 每 15 分钟 | OnCalendar=*:0/15 或 OnUnitActiveSec=15min |
0 2 * * * 每天 2:00 | OnCalendar=*-*-* 02:00:00 |
0 9-17 * * 1-5 工作日每小时整点 | OnCalendar=Mon..Fri *-*-* 09:00:00..17:00:00 |
@daily | OnCalendar=daily |
@monthly | OnCalendar=monthly |
@reboot | OnBootSec=0(开机即触发) |
| 想补跑错过的 | 加 Persistent=yes |
| 随机错峰 | 加 RandomizedDelaySec=5min |
一个完整的迁移例子
# 原 crontab 30 2 * * * root /usr/local/bin/backup.sh # 迁移成 timer # /etc/systemd/system/backup.service [Unit] Description=Nightly backup [Service] Type=oneshot ExecStart=/usr/local/bin/backup.sh # /etc/systemd/system/backup.timer [Unit] Description=Run backup daily at 2:30 [Timer] OnCalendar=*-*-* 02:30:00 Persistent=true RandomizedDelaySec=5min [Install] WantedBy=timers.target sudo systemctl daemon-reload sudo systemctl enable --now backup.timer systemctl list-timers backup.timer
结论:可以直接替代吗?
- 日与星期的 OR 语义在 cron 里是特性,timer 没有对应写法——需要"某日且某星期"就改写 OnCalendar 表达式或命令内
test。 - 普通用户任务:cron 里
crontab -e即可,timer 需要systemctl --user enable --now并loginctl enable-linger 用户名才能开机/后台运行。 - 输出与通知:cron 默认把输出邮件给你;timer 默认把输出丢进 journal。要邮件请配
OnFailure=/告警服务,或StandardOutput=journal。 - 简单场景保留 cron 也完全合理:一两条、分钟级、无需依赖的任务,crontab 依然是最短路径;尤其要兼容多发行版或旧脚本时。
6. 实战速查手册
6.1 crontab 命令
| 命令 | 说明 |
|---|---|
crontab -e | 编辑当前用户任务表(VISUAL/EDITOR 指定编辑器) |
crontab -l | 列出当前用户任务表 |
crontab -r / -r -i | 删除任务表(-i 需确认) |
crontab -T <file> | 测试语法不安装 |
crontab <file> / crontab - | 用文件 / 标准输入整体替换任务表(脚本友好) |
crontab -u user -l | 查看他人任务表(root) |
crontab -V | 版本 |
6.2 常用示例
# 每 5 分钟 */5 * * * * /opt/scripts/ping-check.sh # 每小时的第 15 分 15 * * * * /usr/local/bin/sync-data # 每天 2:30, 输出追加到日志, 错误也进日志 30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1 # 每周一至周五 9 点 0 9 * * 1-5 /usr/local/bin/standup-report # 每月 1 日零点清空临时目录 0 0 1 * * find /var/tmp -type f -mtime +30 -delete # 开机执行一次 @reboot /usr/local/bin/on-boot-tasks.sh # 用 % 传标准输入给命令 0 22 * * 1-5 mail -s "reminder" ops@x.com %记得备份数据 # 任务表统一配置 PATH / MAILTO PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO=ops@example.com */10 * * * * /usr/local/bin/healthcheck
6.3 故障排查
# 1. crond 在跑吗 systemctl status crond systemctl is-active crond # 2. 任务有没有被执行(syslog / journal) journalctl -u crond --since "1 hour ago" grep CRON /var/log/cron # 有些发行版写这里 # 3. 语法对不对(先测再装) crontab -T <file> # 4. 命令为什么没效果 # - PATH 里没有该命令 → 任务表里显式设置 PATH 或写绝对路径 # - 环境变量/家目录不对 → 脚本里 source 需要的配置或用绝对路径 # - 输出去哪了 → 脚本里把输出写到文件 / 设置 MAILTO 收邮件 # 5. 权限被拒 # - 检查 /etc/cron.allow 是否把你排除在外 # - 非 root 不能写 /etc/crontab 与 /etc/cron.d # 6. 任务表损坏(未以换行结尾) # crond 会向 syslog 报 "broken" 警告, 补一个换行再 crontab 命令安装
-T),再看 journal 里的 CRON 记录确认是否触发,最后把脚本里 echo 输出重定向到文件定位失败原因。cron 的环境极简,绝对路径是脚本可靠性的第一保证。
6.4 实战 Workshop
一个从零到排障的动手演练,建议在一台测试机/容器上按顺序做。每个练习都是"目标 → 操作 → 验证 → 要点",每个 要点 直接给出结论,不用再自行推测。全程不要用 root 直接 crontab -e(避免污染环境),先备份再改。
systemctl status crond # 确认守护进程在跑(或 service crond status) which crontab date # 确认命令存在 mkdir -p ~/cronlab/log ~/cronlab/bin alias cl=crontab # 后面练习简写 crontab -l 2>/dev/null | head # 查看当前是否已有任务(有则先备份:crontab -l > ~/cronlab/backup.txt)
练习 1:第一个每分钟任务
目标:建立"编辑 → 生效 → 验证 → 删除"的完整闭环。
# 1) 写一个简单脚本 cat > ~/cronlab/bin/tick.sh <<'EOF' #!/bin/bash echo "$(date '+%F %T') tick" >> "$HOME/cronlab/log/tick.log" EOF chmod +x ~/cronlab/bin/tick.sh # 2) 生成时间表并安装 printf '* * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/job1.txt crontab -T ~/cronlab/job1.txt # 先校验语法 crontab ~/cronlab/job1.txt # 再安装 # 3) 验证(等 70~130 秒看到至少两条) sleep 70; cat ~/cronlab/log/tick.log crontab -l # 确认已安装
要点:cron 是非交互环境,路径没有 login shell 的 PATH(默认仅 /usr/bin:/bin),执行时的工作目录也不保证是家目录,所以相对路径 ./cronlab/... 或裸命令名大概率失败。必须用绝对路径;且脚本内部也要用 $HOME 拼绝对路径来定位日志。若脚本里写相对路径,日志可能落到 crond 的工作目录(通常是 /),甚至因目录不存在而写入失败——这是"任务显示执行了却找不到产物"最常见的原因。
练习 2:时间字段五种写法
目标:亲手验证 *、范围、列表、步进、随机、@昵称的语义。
# 分别安装下面的表,每次等 60~130 秒后 cat 日志比对 # A) 每 5 分钟 printf '*/5 * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j2.txt # B) 每天 8:15(工作日的含义:日与星期 OR) printf '15 8 * * 1-5 %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j2.txt # C) 开机一次 printf '@reboot %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j2.txt crontab -T ~/cronlab/j2.txt && crontab ~/cronlab/j2.txt crontab -l
要点:*/35 一小时只跑 2 次(第 0 分和第 35 分),一天 48 次——步进只在所属字段内生效,不会跨小时累计,"每 35 分钟一次"必须用 shell 判断(见 6.2 速查表)。5~15 是随机范围,解析表时取一次并固定整个守护进程生命周期,所以 crontab -l 看到的是一次性展开的具体值;用途是让一批节点错峰,避免整点风暴。15 8 * * 1-5 的含义是"每个工作日 8:15"(日与星期是 OR 关系)。
练习 3:% stdin 与日志重定向
目标:体会 % 把命令展开成多行 + 喂 stdin 的机制,以及如何优雅处理输出。
# 1) 用 % 给命令喂 stdin printf '* * * * * /usr/bin/tee %s/cronlab/log/feed.txt %%hello%%\n' "$HOME" > ~/cronlab/j3.txt crontab -T ~/cronlab/j3.txt && crontab ~/cronlab/j3.txt sleep 70; cat ~/cronlab/log/feed.txt # 应该有两行 hello # 2) 需要字面 % 时(date 的格式化)必须转义 printf '* * * * * /bin/echo "%%d" >> %s/cronlab/log/pct.log\n' "$HOME" > ~/cronlab/j3b.txt crontab ~/cronlab/j3b.txt; sleep 70; cat ~/cronlab/log/pct.log # 3) 关闭邮件噪音:输出重定向到日志文件 printf '* * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/j3.txt # tick.sh 自带 >>
要点:未转义的 % 会被改写成换行,第一个 % 之后的全部内容作为命令的标准输入;需要字面 %(如 date 的 %d)必须写成 \%。生产环境几乎必加 >> 日志 2>&1 并设 MAILTO="" 的原因:默认 cron 会把命令输出打包邮件发给属主,邮件在无人值守的服务器上堆积且 MTA 不一定可用;重定向落盘后,日志集中、可轮转、可 grep 排障,还不依赖发信链路。
练习 4:系统级任务 /etc/cron.d
目标:掌握系统表与用户表的差异(多一个用户名字段)。
# 需 root:在 /etc/cron.d/ 放一个片段 echo "* * * * * root /usr/bin/touch /tmp/system-alive" | sudo tee /etc/cron.d/alive sleep 70; ls -l /tmp/system-alive # 故意漏掉用户名字段,观察报错 echo "* * * * * /usr/bin/touch /tmp/bad" | sudo tee /etc/cron.d/bad sleep 70; grep -iE "bad|errors?" /var/log/cron # 修复:删掉或补上用户名 sudo rm /etc/cron.d/bad
要点:系统表的语法是"5 个时间字段 + 用户名 + 命令"。用户表没有用户名字段,原样抄进 /etc/cron.d/ 后,crond 会把用户名的位置错误解析——把命令 /usr/bin/touch /tmp/bad 当作用户名、把下一段当命令,整条无法解析导致该表加载失败(syslog 会记 errors)。修复就是补上正确的用户名字段。
练习 5:权限控制 cron.allow / cron.deny
目标:体验白名单/黑名单的生效优先级(allow 存在时 deny 被忽略)。
# 需 root,创建普通测试用户 sudo useradd -m -s /bin/bash cronuser echo "cronuser" | sudo tee /etc/cron.allow sudo -u cronuser crontab -l # 白名单内:正常 sudo crontab -u alice -l # 不在名单内:报 You (alice) are not allowed # 验证 allow 优先:即使把 alice 加进 deny,allow 存在时仍可用 echo "alice" | sudo tee -a /etc/cron.deny # 清理 sudo rm /etc/cron.allow /etc/cron.deny; sudo userdel -r cronuser
要点:allow/deny 的判定:cron.allow 存在则只看它(名单内允许);否则若 cron.deny 存在则排除名单内用户;两者都不存在只有 root 可用。allow 优先于 deny。它们只在 crontab 命令入口做检查,不干预 crond 读取 spool 执行既有任务——所以一个用户被移出白名单后,他已经安装的任务照样跑,除非把对应的时间表删掉(crontab -r)。
练习 6:排障演练
目标:把"任务没跑"的问题现场定位一遍。
# 1) 故意制造 PATH 缺失:命令用裸名 printf '* * * * * nonexistentcmd\n' > ~/cronlab/bad1.txt crontab ~/cronlab/bad1.txt; sleep 70 grep -i nonexistentcmd /var/log/cron # 看到 Command not found # 2) 语法错误排查 printf '99 * * * * %s/cronlab/bin/tick.sh\n' "$HOME" > ~/cronlab/bad2.txt crontab -T ~/cronlab/bad2.txt # 报 minute 越界,未安装 # 3) 末行无换行 printf '*/5 * * * * %s/cronlab/bin/tick.sh' "$HOME" > ~/cronlab/bad3.txt # 注意结尾没有 \n crontab ~/cronlab/bad3.txt; sleep 70 grep -i "broken\|missing" /var/log/cron # 表被判定损坏 # 4) 环境 dump 对比:交互 shell 与 cron 环境的差异 printf '* * * * * env > %s/cronlab/log/cron-env.txt\n' "$HOME" > ~/cronlab/j6.txt crontab ~/cronlab/j6.txt; sleep 70 diff <(env | sort) <(sort ~/cronlab/log/cron-env.txt) | head -20
要点:三个典型故障都源于"cron 环境 ≠ 交互环境":① 裸命令名找不到是因为默认 PATH=/usr/bin:/bin,交互 shell 里 PATH 被 login 脚本扩展过,cron 里没有——日志出现 Command not found;② 99 越界会被 crontab -T 直接拦截,不会安装;③ 末行无换行会让 crond 判定整表损坏、任务完全不执行(syslog 告警)。第 4 步的 diff 显示 cron 环境缺失大量交互变量(DISPLAY、LS_COLORS、自定义别名/函数等)。这就是为什么生产标准是:命令与脚本内全用绝对路径、表头显式 PATH=、脚本不依赖任何交互环境。
练习 7:自选挑战
综合以上技能完成一个真实小需求(二选一):
- 日志轮转:写一个脚本,每天 0:05 把
~/cronlab/log/tick.log重命名压缩为tick.log.YYYYMMDD.gz(只保留最近 7 份)。 - 错峰备份:用
RANDOM_DELAY=30让一批节点上的备份任务随机错开 30 分钟内启动,避免集中打满磁盘。
完成后自查:语法先 crontab -T;命令全绝对路径;输出有日志、无邮件噪音;脚本幂等可重入。
7. crond 时间调度实现(syscall 视角)
crond 并不是"每秒轮询",而是用系统调用精确睡到下一个分钟边界再醒来调度。这套机制来自 cronie 的 src/cron.c 主循环,理解它就能回答两个常见问题:"为什么 crond 一醒来就知道该跑哪些任务"、"为什么 cron 最小粒度是分钟"。
7.1 三套分钟时钟
cronie 主循环维护三个"分钟时钟"(源码注释原文如此):
- virtualTime — 理论应处理到的"虚拟分钟",单调递增;循环顶部时,该分钟的所有任务都已被调度完。
- timeRunning — 上次实际醒来的分钟。
- clockTime — 最近一次
set_time()读到的真实分钟((StartTime + GMToff) / 60换算而来)。
while (!got_sigintterm) {
do {
cron_sleep(timeRunning + 1, &database); /* 睡到"下一分钟"那一秒 */
set_time(FALSE);
} while (clockTime == timeRunning); /* 分钟没变就继续睡 */
timeRunning = clockTime;
timeDiff = timeRunning - virtualTime;
load_database(&database); /* inotify 或全量重读 */
...
find_jobs(virtualTime, &database, ...); /* 位图匹配, 命中即入队 */
job_runqueue(); /* fork 执行 + 收割 */
}
clockTime 更新为当前分钟;虚拟时钟(virtualTime)用于在睡过头/时钟跳变时"补跑错过的分钟"。
7.2 syscall 链路时序图
一次"睡到下一分钟 → 醒来调度 → 执行任务"的完整系统调用链路:
用例演示
# 用 strace 直接观察 crond 的 nanosleep 与 fork/exec
sudo strace -f -e trace=nanosleep,clock_gettime,fork,execve,wait4 -p $(pgrep crond) -o /tmp/crond.strace
sleep 70 # 跨过一个分钟边界后看 /tmp/crond.strace
# 可看到: clock_gettime → nanosleep({60}) → ... → fork → execve(/bin/sh -c ...) → wait4
7.3 syscall 清单
| 环节 | 系统调用 | 作用 |
|---|---|---|
| 读时钟 | time() / gettimeofday() | 取墙钟秒/微秒,换算当前分钟;现代 Linux 走 vDSO(内核 clock_gettime)不陷入内核 |
| 等待分钟边界 | sleep() → nanosleep() | 阻塞到目标秒数,内核用 hrtimer 高精度定时器实现,不是轮询、不占 CPU |
| 信号打断 | rt_sigaction / rt_sigreturn | 注册 SIGHUP/SIGCHLD/SIGURG/SIGTERM;任一信号可打断 nanosleep,之后重算剩余秒数继续睡 |
| 拉起任务 | fork() + execve() | 命中任务由 job_runqueue() 以 /bin/sh -c 拉起子进程执行 |
| 回收子进程 | waitpid(WNOHANG) | sigchld_reaper 在 SIGCHLD 后收割退出的任务子进程,避免僵尸 |
clock_gettime/gettimeofday 等只读时钟调用映射到用户态,省去系统调用陷入;hrtimer(高精度定时器)是内核的定时唤醒机制,nanosleep 依赖它;EINTR表示睡眠被信号打断。
7.4 虚拟时钟追赶与 DST
醒来后计算 timeDiff = timeRunning - virtualTime,按差值分四种情况处理:
| timeDiff | 场景 | 处理 |
|---|---|---|
1 | 最常见,正常准点醒来 | 快速路径,直接 find_jobs 调度当前分钟 |
small(晚醒几分钟) | 系统繁忙或 sleep 被打断 | virtualTime++ 逐分钟补跑错过的任务 |
medium(多了一小时) | DST 开始 / 时钟被调快 | 通配任务跑一次,固定时刻任务逐个补跑 |
negative(少了一小时) | DST 结束 / 时钟被调慢 | 只跑通配任务,固定任务不重复执行 |
large(大幅跳变) | 时钟被大幅修改 | 把 virtualTime 直接跳到当前,全量跑一次 |
* 的任务。
7.5 位图匹配与分钟粒度
每个任务条目的时间字段在解析时被展开成位图(bitfield):minute 60 位、hour 24 位、month 12 位、dom/dow 31/8 位。调度时把当前时间的五个分量拿出来逐位测试,全部命中才执行:
if (bit_test(e->minute, minute) && bit_test(e->hour, hour) &&
bit_test(e->month, month) &&
(((e->flags & DOM_STAR) || (e->flags & DOW_STAR))
? (bit_test(e->dow, dow) && bit_test(e->dom, dom))
: (bit_test(e->dow, dow) || bit_test(e->dom, dom))))
job_add(e, u); /* 命中即入队,job_add 保证同一分钟不重复入队 */
OnCalendar=*-*-* *:*:00。
7.6 hrtimer 原理(高精度定时器)
7.2 里说 nanosleep 靠"内核 hrtimer"实现。hrtimer(high-resolution timer)是 Linux 内核独立于 jiffies 定时轮(timer wheel)的高精度定时器子系统——crond 睡得准不准,取决于它。
为什么需要两套定时器子系统
- 旧 timer wheel(kernel/timers.c):围绕
jiffies(HZ 节拍)与 32 位计数精心优化,擅长"超时"场景——绝大多数超时定时器还没到点就被删掉,要求近乎零开销;但它的级联(cascading)有 O(N) 不可预测开销,且精度被 jiffies 粒度(通常 1~10ms)限制,做不了高精度。 - hrtimer(kernel/hrtimer.c):设计目标就是精度。所有内核逻辑用64 位纳秒的
ktime_t,不绑定 jiffies 或任何粒度;nanosleep、clock_nanosleep、itimers、posix-timers 全部改用 hrtimer。
数据结构:红黑树 + pending 链表
hrtimer 用红黑树(rb-tree)按"到期时间"有序存放定时器,另配一条独立的 pending 链表供到期处理快速取用,无需遍历整棵树。每 CPU 一个实例,且按时钟类型分为多个 clock base(CLOCK_MONOTONIC、CLOCK_REALTIME、CLOCK_BOOTTIME 等),到期时间存绝对时间——因此 settimeofday/NTP 校时无需改动红黑树,天然解决旧实现"全量摘出再重插"的难题。
ktime_t // 64 位纳秒时间类型 hrtimer_init(&t, CLOCK_MONOTONIC, HRTIMER_MODE_ABS); hrtimer_start(&t, expiry, HRTIMER_MODE_ABS); /* 按到期时间插入红黑树 */
到期机制:只关心"最早到期"
调度器每次只需看红黑树的最左节点(最早到期的定时器),通过 clockevents 把这个时刻编程进硬件定时器(见 7.7);到点时硬件产生中断,进入 hrtimer_interrupt() 弹出所有到期节点、执行回调、唤醒对应任务。没有到期任务时 CPU 进入空闲(hlt/mwait),靠这个硬件中断把自己叫醒。
用例演示
# 看 crond 到底睡在哪个 hrtimer clock base(ftrace 观测) sudo trace-cmd record -e timer:hrtimer_start -e timer:hrtimer_expire_entry sleep 70 trace-cmd report | grep -E "hrtimer_(start|expire)" | head # 确认系统高精度定时是否开启 cat /sys/kernel/debug/clock/boot_clock/resolution # 通常显示 1(纳秒级)
7.7 硬件定时器与 ISA 级控制
hrtimer 只是软件侧的时间组织,真正"到点叫醒 CPU"的是硬件定时器 + 中断投递。在 x86 上,内核通过三类 ISA(指令集体系结构)手段控制它们:端口 I/O 指令、MSR 读写指令、MMIO 访存。理解这条链路,"CPU 为什么恰好在一分钟后醒来"就有了最终答案。
x86 常见定时硬件与编程接口
| 硬件定时器 | ISA 编程接口(指令) | 特点 |
|---|---|---|
| PIT 8254(可编程间隔定时器) | 端口 I/O:in/out 访问 0x40-0x43,写模式字 + 计数值 | 最古老,周期/单次,精度低,传统 IRQ0 源 |
| 本地 APIC 定时器(Local APIC Timer) | rdmsr/wrmsr 读写 IA32_APIC_BASE(MSR 0x1B)定位 APIC 页,再经 MMIO 写 LVT 定时器寄存器(偏移 0x320)与初始计数(0x380) | 现代首选:每 CPU 一个、微秒级、周期/单次、可设为 boot 时钟 |
| HPET(高精度事件定时器) | MMIO:主计数器 + 各比较器寄存器,经 PCI 枚举映射到内存地址空间 | 周期定时,多个比较器,可路由中断 |
| TSC(时间戳计数器) | rdtsc / rdtscp 指令直接读 64 位周期计数 | 只用于读时间,不产生中断,常作 clocksource |
| IO-APIC | MMIO(I/O 中断路由表) | 把外部设备/PIT/HPET 的中断路由到各 CPU |
中断投递的 ISA 链路(以本地 APIC 定时器为例)
- 内核把"下一分钟边界"换算成周期数,
wrmsr/MMIO 写入 LVT 定时器寄存器与初始计数; - 硬件开始倒计数,计数到 0 时本地 APIC 产生一条定时器中断;
- 本地 APIC 把中断按向量号投递给 CPU,CPU 依据
lidt装载的 IDT(中断描述符表)找到入口,跳到hrtimer_interrupt()(此时IF标志若被cli清零则屏蔽,中断挂起等sti); - 处理完到期回调后
iret返回,被唤醒的任务(crond)继续执行。
idle 与"谁控制指令流"
没有定时任务时,CPU 执行 hlt(或 monitor/mwait)停止指令流进入低功耗空闲;到点的定时器中断是重新"夺取控制权"的信号——这正是"ISA 如何控制"的落点:用 hlt 主动让出执行,用 IDT 让硬件事件把指令流拉回来。crond 的整条链路因此是:nanosleep → hrtimer 红黑树 → clockevents → 本地 APIC 定时器(wrmsr/MMIO)→ 倒计时中断 → IDT → hrtimer_interrupt → 唤醒 crond → 7.1~7.5 的分钟匹配调度。
# 观察 crond 睡眠时 CPU 是否真的 halt(systat 的 mpstat %idle,或 powertop) sudo powertop --html=/tmp/power.html # 查看当前用的 clocksource cat /sys/devices/system/clocksource/clocksource0/current_clocksource # 通常 tsc
in/out)、MSR(rdmsr/wrmsr)、MMIO、中断指令(int/iret)、特权位控制(cli/sti)、空闲指令(hlt/mwait);MSR(模型专属寄存器)须 CPL 0 且用 wrmsr 写;LVT(Local Vector Table)本地 APIC 的中断向量寄存器组;IDT由 lidt 装载,中断向量在此查表进入处理函数;IF(中断允许标志)由 sti/cli 置/清,清零时屏蔽可屏蔽中断。